Skip to content

feat(resizable): DLT-2097 add DtResizable panel layout component#1162

Merged
Joshua Hynes (hynes-dialpad) merged 63 commits intostagingfrom
feat/DLT-2097-resizable-component
Apr 6, 2026
Merged

feat(resizable): DLT-2097 add DtResizable panel layout component#1162
Joshua Hynes (hynes-dialpad) merged 63 commits intostagingfrom
feat/DLT-2097-resizable-component

Conversation

@hynes-dialpad
Copy link
Copy Markdown
Member

@hynes-dialpad Joshua Hynes (hynes-dialpad) commented Mar 31, 2026

Obligatory GIF (super important!)

Obligatory GIF

🛠️ Type Of Change

  • Fix
  • Feature
  • Performance Improvement
  • Refactor

📖 Jira Ticket

DLT-2097

Stories:

  • DLT-3234 — Core resize engine + constraints
  • DLT-3235 — Persistence + storage adapter
  • DLT-3236 — Keyboard accessibility (W3C separator pattern)
  • DLT-3237 — Offset, docs, CSS extraction

📖 Description

New DtResizable component system — a resizable panel layout ported from Beacon and genericized for Dialtone. Three components, 10 composables, 160 tests across 8 test files, 7 Storybook stories, and a full VuePress docs page.

Components

  • DtResizable — Layout orchestrator (direction, collapse rules, storage, offset, i18n messages)
  • DtResizablePanel — Individual panel (constraints, collapse, initial size)
  • DtResizableHandle — Drag handle (W3C keyboard resize, ARIA separator, offset positioning)

Key Features

  1. Core resize — Drag handles between panels with proportional sizing, row/column direction
  2. Dual constraint hierarchy — userMin/Max (drag limits) + systemMin/Max (viewport resize limits)
  3. Collapse — Manual + auto-collapse with priority-based collapseRules, space allocation strategies
  4. PersistencestorageKey for localStorage, :storage prop for custom adapters (Pinia/Vuex/API)
  5. Keyboard accessibility — W3C separator pattern: always-focusable handles, arrow keys resize on focus, Enter collapses, Home/End for min/max, R resets, Escape blurs. ARIA live announcements.
  6. Offset positioning — Parent-level offsetElement/offsetAmount props. Handles and panel content offset automatically from fixed headers or toolbars.
  7. i18n — All ARIA strings configurable via messages prop with {placeholder} templates

Architecture

  • Pure layout engine (computeLayout.ts) — single source of truth for all panel positions. Zero Vue dependencies.
  • One-way data flow: User Action → savedState → computeLayout reruns → Vue renders
  • Shadow DOM drag (useResizableDrag.ts) — inline styles during pointer move for 60fps, commits to savedState on pointer up
  • Keyboard follows same shadow → commit discipline as drag
  • Constraint resolver (constraintResolver.ts) — 3-tier system (user → system → collapse) applied within computeLayout
  • Styles in packages/dialtone-css/lib/build/less/components/resizable.less

📦 Cross-Package Impact

Package Changes Downstream Impact
dialtone-vue New component (3 SFCs + 10 composables) Documentation site needs component page
dialtone-css New resizable.less component styles Included in CSS build
dialtone-documentation New VuePress page + sidebar entry + 5 example components None

📄 Documentation Artifacts

Artifact Status Notes
Vue source 3 components in packages/dialtone-vue/components/resizable/
Tests 160 tests across 8 test files
Storybook stories 7 stories + MDX docs page
Component docs JSON All 3 components generate props/events/slots via vue-docgen-api
VuePress docs docs/components/resizable.md with full API tables
MCP server data ⚠️ Will update after next MCP server data rebuild
components_list.js All 3 components registered
Package exports Exported from packages/dialtone-vue/index.js

💡 Context

This is an FY27 Q1 Key Result under the "Dialtone Next and Beacon Alignment" initiative (DLT-2979). The resizable panel system was originally built for Beacon's sidebar/main/peek layout. This PR ports it to Dialtone as a first-class component, genericized for any resizable panel layout use case.

Reviewer feedback addressed

  • Brad: computeLayout as single source of truth (eliminated 15 direct mutations across 3 files), deleted ~510 lines of dead code (useDOMCache, useResizablePanelSizing, handleViewportResize, dead exports), keyboard mutation discipline
  • Francis: W3C separator keyboard pattern (deleted edit mode), CSS extraction to dialtone-css, d-* prefix, design tokens, ARIA (controls, valuetext, disabled, i18n), Enter/Home/End/R keys, test quality (|| true removal, drag tests, event emission tests)
  • Codex bot: P1 edit mode listener (moot — deleted), P1 keyboard cursor (confirmed false positive), P2 resetBehavior (fixed — all modes differentiated)

📝 Checklist

For all PRs:

  • I have ensured no private Dialpad links or info are in the code or pull request description (Dialtone is a public repo!).
  • I have reviewed my changes.

Port resizable panel system from beacon-app as DtResizable.
Three components (DtResizable, DtResizablePanel, DtResizableHandle),
10 composables with three-layer architecture (pure engine, state
management, orchestration), drag interaction with shadow DOM for
60fps, and 63 passing tests across 4 Storybook stories.

V1 of 7 vertical slices — core layout engine only.
… control (V2)

- Re-add panels and spaceAllocationStrategy props to dt_resizable.vue
- Wire spaceAllocationStrategy through scoped slot and defineExpose
- Add 37 V2 tests covering constraint enforcement, auto-collapse rules,
  space allocation strategies, and programmatic control API surface
- Add 3 Storybook stories: Constraints, Collapsible, Programmatic
- Register V2 stories in dt_resizable.stories.js index
…ce (V3)

- Add ResizableStorageAdapter interface and ResizableStoragePanelData type
- Refactor useResizableStorage with localStorageAdapter(key) factory
- Add :storage prop for custom adapter injection (Pinia, Vuex, IndexedDB)
- Wire storageAdapter through useResizableGroup options
- Custom adapter takes precedence over storageKey when both provided
- 28 tests: save/load cycle, validation, corrupted data, adapter precedence
- 2 stories: localStorage persistence demo, custom adapter example
…and package exports (V7)

- Register dt_resizable, dt_resizable_panel, dt_resizable_handle in components_list.js
- Export resizable components and common/composables from package index
- Add Resizable entry to site-nav.json sidebar navigation
- Create VuePress docs page with usage, constraints, collapsible, persistence,
  peek overlay, keyboard accessibility, programmatic control, and full API tables
- Extract DEFAULT_PANEL_SIZE and MIN_PANEL_SIZE_PX constants (was '50p'
  hardcoded in 9 locations, magic number 10 in 3)
- Scope useDOMCache MutationObserver to container via observeRoot option
  (was observing document.body globally, invalidating on all DOM changes)
- Add ref-counted cleanup for announcement DOM element (was never removed)
- Remove void statements in keyboard resize callback (was suppressing
  unused-var warnings for no-op)
- Remove dead useResizableGroupSetup export from barrel
…e dead code

- Add clampSize() and clampToTier() to constraintResolver.ts as the
  single source of truth for constraint clamping. Replace 4 duplicate
  implementations across useResizablePanelState, useResizableCalculations,
  useResizablePanelControls, and computeLayout.
- Replace hardcoded SIZE_TOKENS map with runtime CSS custom property
  resolution (reads --dt-size-{token} from getComputedStyle). Falls
  back to static map in test/SSR environments. Percentages now parsed
  dynamically from the 'p' suffix pattern.
- Remove dead useResizableGroupSetup (404 lines) — replaced by
  useResizableGroup.ts in V1, never imported by any component.
  useResizableCore.ts reduced from 623 to 195 lines.
- Fix handle class prop to accept String, Object, Array (was String-only)
- Replace hardcoded 2px in focus-visible with --dt-size-200/300 tokens
- Add comment explaining panelsChanged custom comparator rationale
- Note storageKey/storage capture-at-mount limitation
Add missing story imports and exports for ResizableKeyboard,
ResizablePeekHover, ResizablePeekButton, and ResizableOffset
templates in dt_resizable.stories.js.
…t.each

Replace repetitive it() blocks with parameterized it.each tables
in KEYBOARD_INCREMENTS and offsetDirection test suites.
…announcements

- Add ResizableEditModeMessages interface with optional fields for all
  edit mode announcements (editModeActivated, editModeDeactivated,
  editModeNoHandles, panelsReset, allPanelsReset) with English defaults
- Add ResizableKeyboardMessages interface with resizeAnnouncement template
  using {beforeId}, {afterId}, {beforePx}, {afterPx}, {action},
  {incrementType} placeholders
- Add ariaLabel prop to DtResizableHandle for i18n override of the
  default computed aria-label
- Add messages prop to DtResizable, wired through provide/inject to
  both useResizableEditMode and useResizableKeyboard
- Add RESIZABLE_MESSAGES_KEY injection key for cross-component messaging
…tcuts and aria-description

- Remove createInstructions() that injected a hidden DOM element with
  hardcoded English instructions linked via aria-describedby
- Add aria-keyshortcuts attribute to handle: "Control+e" when inactive,
  full shortcut list when in edit mode
- Add aria-description attribute with contextual hint: edit mode entry
  prompt when inactive, full controls summary when active
- Add editModeDescription and editModeActiveDescription to
  ResizableEditModeMessages interface for i18n support
- Update tests to verify new ARIA attributes instead of DOM element
- Remove stale dt-resize-instructions cleanup from test afterEach hooks
@wiz-inc-55b470eb7e
Copy link
Copy Markdown

wiz-inc-55b470eb7e Bot commented Mar 31, 2026

Wiz Scan Summary

Scanner Findings
Vulnerability Finding Vulnerabilities -
Data Finding Sensitive Data -
Secret Finding Secrets -
IaC Misconfiguration IaC Misconfigurations -
SAST Finding SAST Findings -
Software Management Finding Software Management Findings -
Total -

View scan details in Wiz

To detect these findings earlier in the dev lifecycle, try using Wiz Code VS Code Extension.

…compat

All other Dialtone component barrels use index.js (not index.ts).
The TS compiler cannot resolve .vue module imports from .ts files
without vue shim declarations. Renaming to .js matches the
established convention and fixes the build.
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 22b40c7af6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "Codex (@codex) address that feedback".

Comment thread packages/dialtone-vue/components/resizable/composables/useResizableEditMode.ts Outdated
Comment thread packages/dialtone-vue/components/resizable/composables/useResizableKeyboard.ts Outdated
The typedoc build-functions target generates "Date and Time" docs
from common/*/index.js. The new common/composables/ directory
(useDOMCache) is not part of that scope. Excluding it prevents
potential resolution issues with .ts entry points in CI.
…ical stories

The Default and Vertical stories lacked docs.source.code overrides,
causing Storybook to auto-generate incorrect code showing the wrapper
component tag (dt_resizable) instead of the actual template with
DtResizablePanel and DtResizableHandle children.
…ult direction

- Convert markdown tables to HTML tables for MDX rendering compatibility
- Remove unnecessary direction="row" from code examples (it's the default)
@github-actions
Copy link
Copy Markdown
Contributor

Please add either the visual-test-ready or no-visual-test label to this PR depending on whether you want to run visual tests or not.
It is recommended to run visual tests if your PR changes any UI. ‼️

@hynes-dialpad Joshua Hynes (hynes-dialpad) added the no-visual-test Add this tag when the PR does not need visual testing label Mar 31, 2026
…over

- Add d-w100p class to all story panel content divs so they fill width
- Add styles for __indicator element (was unstyled in template)
- Move hover/active background-color to __indicator with 150ms transition
Replace physical properties (top/left/right/bottom) with logical
properties (inset-inline-start/end, inset-block, inline-size).
Container sets writing-mode: vertical-lr for column direction,
so all positioning rotates automatically.

- Panel: inset-block: 0 + insetInlineStart/End (was top/bottom: 0 + left/right)
- Handle: inset-block + inline-size (was separate row/column blocks)
- Hit area: inset shorthand (was 4 physical properties)
- Indicator: inset: 0 (was duplicate row/column blocks)
- Panel content: writing-mode: horizontal-tb reset for normal text
- Removed direction-switching JS in handle positioning
Define --dt-resizable-handle-color-surface on the handle root,
defaulting to --dt-color-surface-info-strong. All hover, active,
and focus-visible styles reference the component variable instead
of the raw token — single place to update for theming.
- Add panelMap computed (Map<id, PanelState>) to useResizableGroup
  for O(1) lookups — replaces 14 .find() calls across 4 files
- Replace setTimeout ARIA debounce with computed properties
  (ariaValueNow/Min/Max derive directly from panel state)
- Replace JSON.stringify watch with deep:true on panelConfig computed
- Remove unused direction param from useResizeHandling
- Fix silent catch in useResizableOffset
- Migrate from mouse+touch dual listeners to Pointer Events
  (pointerdown/pointermove/pointerup/pointercancel). Halves the
  event listener code in useResizableDrag. Added touch-action: none
  to handle CSS for proper pointer capture on touch devices.
- Replace array-index handle registration with DOM-order resolution.
  Handle now queries its sibling index at render time instead of
  relying on mount-order array position. Stable under dynamic
  mount/unmount/reorder.
- Remove getEventPosition from useResizeHandling (dead after
  pointer events migration — drag reads clientX/Y directly).
- computeLayout now treats locked panels as fixed (won't resize
  during redistribution)
- Collapsing a panel clears manualTargetRatio on siblings so they
  fill the freed space instead of maintaining their drag ratio
- Replace ensureAtLeastOneUnlocked direct mutation with
  findPanelToForceUnlock that returns the ID for the caller
  to update via savedState
Constraints (userMinSize/userMaxSize) handle the same use cases.
Removes locked/lockPanel/unlockPanel/findPanelToForceUnlock from
types, composables, component expose, tests, stories, and docs.
Offset is now a DtResizable prop instead of per-handle. The parent
measures the offset element once and provides both handle styles
(inset-block-start) and panel content styles (padding-block-start)
to all children via context.

- offset-element: CSS selector for fixed header/toolbar
- offset-amount: explicit pixel value (overrides element measurement)
- offset-direction: 'start' (default), 'end', or 'both'

Handles and panels share the same offset automatically — no manual
panel padding needed by consumers.
Replace || true hack with proper pointerdown trigger and JSDOM
clientWidth mock so the drag actually activates in the test env.
@hynes-dialpad
Copy link
Copy Markdown
Member Author

Fixed — the keyboard system was completely rewritten to use the W3C separator pattern (025912819). The old edit-mode keyboard had a bug where it used container-relative coordinates incorrectly for handles after the first panel. The new implementation:

  1. Each handle is independently focusable (tabindex="0")
  2. Arrow keys resize the adjacent panel pair relative to the focused handle
  3. Each handle resolves its own beforePanelId/afterPanelId from DOM order
  4. No global state shared between handles

The three-panel keyboard issue should be resolved. Please re-test on the deploy preview.

@hynes-dialpad
Copy link
Copy Markdown
Member Author

Removed — the peek feature was cut from this PR in bdd2aff. It was a Beacon-specific feature with usability issues (as you found — the trigger was clipped inside a zero-width collapsed panel).

The core component now focuses on: resize, collapse/expand, keyboard a11y, persistence, and constraints. Peek overlay can be built as a Beacon extension later if needed.

@francisrupert
Copy link
Copy Markdown
Contributor

Joshua Hynes (@hynes-dialpad) Great updates. I've gone through and see no further issues. I've stress-tested the interaction nuances and it feels bulletproof and snappy.

Deferring approval to Brad Paugh (@braddialpad).

Copy link
Copy Markdown
Contributor

@braddialpad Brad Paugh (braddialpad) left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks much cleaner, thanks for the changes.

Approving, with a couple cleanup items

Comment on lines +461 to +501
export function allocateSpaceOnPanelOpen(
newPanelSize: number,
allPanels: ResizablePanelState[],
strategy: SpaceAllocationStrategy = 'proportional'
): Map<string, number> {
const newSizes = new Map<string, number>();
const availablePanels = allPanels.filter(p => !p.collapsed);

if (availablePanels.length === 0 || newPanelSize <= 0) {
allPanels.forEach(p => newSizes.set(p.id, p.pixelSize));
return newSizes;
}

if (strategy === 'preserve-manual') {
const donors = availablePanels.filter(p => p.manualTargetSize === undefined);
const manualPanels = availablePanels.filter(p => p.manualTargetSize !== undefined);

if (donors.length === 0) {
return allocateSpaceOnPanelOpen(newPanelSize, allPanels, 'proportional');
}

const totalDonorSpace = donors.reduce((sum, p) => sum + p.pixelSize, 0);
if (totalDonorSpace < newPanelSize) {
console.warn(
`[resizable] preserve-manual: Donor panels only have ${totalDonorSpace}px, ` +
`need ${newPanelSize}px. Falling back to proportional.`
);
return allocateSpaceOnPanelOpen(newPanelSize, allPanels, 'proportional');
}

applyProportionalAllocation(donors, newPanelSize, newSizes);
manualPanels.forEach(p => newSizes.set(p.id, p.pixelSize));
includeUnchangedPanels(allPanels, newSizes);
} else {
// Default: proportional
applyProportionalAllocation(availablePanels, newPanelSize, newSizes);
includeUnchangedPanels(allPanels, newSizes);
}

return newSizes;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After the recent changes, I believe this is no longer used.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 9e6f7fe — deleted allocateSpaceOnPanelOpen, applyProportionalAllocation, includeUnchangedPanels (~80 lines), the SpaceAllocationStrategy type, barrel export, and associated tests.

Comment on lines +143 to +148
function commitPanelSize (panelId, pixels) {
const rounded = Math.round(pixels);
const cSize = group.containerSize.value;
const ratio = cSize > 0 ? rounded / cSize : undefined;
group.updateSavedPanel(panelId, { pixelSize: rounded, manualTargetRatio: ratio });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

function duplicated in useResizablePanelControls.ts. Pretty minor, but could cause one to get out of sync if changes are ever made to it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9e6f7fe — removed the duplicate from resizable.vue. The single definition lives in useResizablePanelControls, now exported from its return object. resizable.vue destructures it: const { commitPanelSize, ... } = useResizablePanelControls({...}).

Comment on lines +202 to +207
function commitPanelSize(panelId: string, pixels: number): void {
const rounded = Math.round(pixels);
const cSize = containerSize.value;
const ratio = cSize > 0 ? rounded / cSize : undefined;
updateSavedPanel(panelId, { pixelSize: rounded, manualTargetRatio: ratio });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

duplicate I earlier mentioned.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — single definition, no duplicate. See above.

}
}

function applyResize(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A fairly minor issue here, I'll leave it up to you if you think it's worth fixing. I'll paste Claude's summary of it because I think it explains it clearly.

Drag path (useResizableDrag.ts):

  • During move (line 311–315): writes inline styles to DOM only. Reactive state untouched.
  • On mouseup, commitDrag (line 330–332): clears inline styles first, then calls onDragEnd (line 339) which commits to reactive state.

Two clearly separated moments. Vue never sees competing style sources.

Keyboard path (useResizableKeyboard.ts), inside applyResize (line 167–203):

  • Lines 180–195: writes inline styles to DOM for immediate visual feedback.
  • Lines 197–202: immediately calls onResize(...) in the same function, which commits to reactive state.

Both happen synchronously in the same call. Vue then schedules a re-render which will overwrite the inline styles with the computed layout values in the next frame.

Why it usually works fine: Vue's async re-render is fast enough that the inline styles get replaced cleanly before the next paint in most cases. The visual result is correct.

The actual risk: if anything reads DOM positions (e.g. element.style.insetInlineStart) immediately after a keyboard resize — during the window between applyResize returning and Vue's re-render completing — it would get the keyboard-written inline value,
not the layout-computed value. Any code that introspects element positions for further calculations could act on stale data in that window.

The fix would mirror the drag approach: in applyResize, after calling onResize, clear the inline styles immediately rather than leaving Vue to do it a frame later. That way the DOM is always exclusively owned by one system at a time.

It's low severity — it doesn't cause visual glitches in normal use — but the inconsistency is a latent bug surface if the component grows.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9e6f7feapplyResize now clears inline styles immediately after calling onResize(), matching the drag path's discipline. Added a clearInlineStyles() helper that zeroes out insetInlineStart, insetInlineEnd, and inlineSize on both panels and the handle. Vue exclusively owns the DOM from the moment onResize returns.

- Remove dead allocateSpaceOnPanelOpen + helpers (~80 lines)
- Deduplicate commitPanelSize: single definition in composable,
  resizable.vue destructures from return instead of defining its own
- Fix keyboard shadow→commit discipline: clear inline styles after
  onResize so Vue exclusively owns the DOM (matches drag pattern)
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 6, 2026

✔️ Deploy previews ready!
😎 Dialtone documentation preview: https://dialtone.dialpad.com/deploy-previews/pr-1162/
😎 Dialtone-vue preview: https://dialtone.dialpad.com/vue/deploy-previews/pr-1162/

@hynes-dialpad Joshua Hynes (hynes-dialpad) merged commit c6bd3bc into staging Apr 6, 2026
26 checks passed
@hynes-dialpad Joshua Hynes (hynes-dialpad) deleted the feat/DLT-2097-resizable-component branch April 6, 2026 20:20
Brad Paugh (braddialpad) pushed a commit that referenced this pull request Apr 7, 2026
# [8.78.0](dialtone-css/v8.77.0...dialtone-css/v8.78.0) (2026-04-07)

### Features

* **Resizable:** DLT-2097 add DtResizable panel layout component ([#1162](#1162)) ([c6bd3bc](c6bd3bc))
Brad Paugh (braddialpad) pushed a commit that referenced this pull request Apr 7, 2026
# [3.219.0](dialtone-vue/v3.218.5...dialtone-vue/v3.219.0) (2026-04-07)

### Features

* **Resizable:** DLT-2097 add DtResizable panel layout component ([#1162](#1162)) ([c6bd3bc](c6bd3bc))
Brad Paugh (braddialpad) pushed a commit that referenced this pull request Apr 7, 2026
# [9.178.0](dialtone/v9.177.2...dialtone/v9.178.0) (2026-04-07)

### Features

* **Resizable:** DLT-2097 add DtResizable panel layout component ([#1162](#1162)) ([c6bd3bc](c6bd3bc))
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-visual-test Add this tag when the PR does not need visual testing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants